import datetime

from django.db import models
from django.contrib.contenttypes import generic
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.contenttypes.models import ContentType

class TransitionManager(models.Manager):
    """A manager for retrieving timelines."""
    def current_of_type_for_object(self,model,object):
        """Returns the current 'x' instance related to the object.

        Given a model m (instance or class) and an object, this method
        returns the current object of type ct which is related to the object.

            page = Page.objects.get(pk=1)
            # returns the current Style object related to the Page instance
            current_style = Transition.timeline.current_of_type_for_object(
                                Style, page)
        """
        referring_ct = ContentType.objects.get_for_model(model)
        referenced_ct = ContentType.objects.get_for_model(object)
        try:
            current = self.filter(referenced_id=object.pk,
                                  referenced_model=referenced_ct,
                                  referring_model=referring_ct,
                                  change_time__lte=datetime.datetime.now())[0]
        except IndexError:
            current = None
        return current
    def get_for_type_against_object(self, candidate, object):
        """Returns a timeline of a model type against a specific object.

        The arguments to this method are two objects, a candidate (used
        to sniff the content type) and a referenced object.

        It returns a QuerySet of Transition objects, where the referenced
        object is as given, and the referring objects match the content
        type of the candidate. For example, if you have a Page p and
        Style s:
            
            timeline = Transition.timeline.get_for_type_against_object(s, p)
            future = timeline.filter(change_time__gte=datetime.datetime.now())
            past = timeline.filter(change_time__lte=datetime.datetime.now())
        """
        candidate_type = ContentType.objects.get_for_model(candidate)
        target_type = ContentType.objects.get_for_model(object)
        id = object.id
        return self.filter(referenced_model=target_type,
                           referenced_id=id,
                           referring_model=candidate_type)
    def create_transition(self, referenced, referring, change_time, notes=""):
        """Create a new transition object.

        Given referenced and referring objects, a timestamp, and
        optional notes, this method creates and returns a Transition
        object representing the relationship.

        For example, if you have ``Page`` and ``Sponsor`` objects,
        where a Sponsor is attached to a Page, you might do this:

            Transition.objects.create_transition(page1,
                                                 sponsor1,
                                                 jan_1st,
                                                 "new sponsor for the new year")
        """
        referenced_model = ContentType.objects.get_for_model(referenced)
        referenced_id = referenced.id
        referring_model = ContentType.objects.get_for_model(referring)
        referring_id = referring.id
        new_transition = self.create(referenced_model = referenced_model,
                                     referenced_id = referenced_id,
                                     referring_model = referring_model,
                                     referring_id = referring_id,
                                     change_time = change_time,
                                     notes = notes)
        return new_transition
    def get_for_object(self,obj):
        """Returns a dictionary of timelines.

        Each timeline is a list of Transition objects, keyed on (ie,
        grouped by) the ContentType of the referring model.  For example,
        say you have three models:

            Page    - referenced model
            Style   - referring model #1
            Sponsor - referring model #2

            p = Page.objects.all()[0]
            timeline = Transition.timeline.get_for_object(p)
            # timeline will be
            # { 'style' : [<Transition>, <Transition>],
            #   'sponsor' : [<Transition>, <Transition>] }
        """
        ctype = ContentType.objects.get_for_model(obj)
        id = obj.id
        all = self.filter(referenced_model=ctype,
                          referenced_id=id)
        # because 'all' came back in date order (set on Transition._meta), we
        # can safely cycle through it and append things to the lists when we
        # discover them
        grouped= {}
        ct_names = {}
        for transition in all:
            referring_type = transition.referring_model.name
            if not grouped.get(referring_type,False):
                grouped[referring_type]=[]
            grouped[referring_type].append(transition)
        return grouped


class Transition(models.Model):
    """Timed link between two objects.

    This model implements a timed ForeignKey relationship where the
    models are arbitrary (generic) on both sides.  It's just two
    GenericForeignKeys and a timestamp.  This makes writing reusable
    standalone content models simpler, enabling you to build 'libraries'
    of them.

    The foreign key attributes use the dry language of database
    terminology, in the absence of anything better.  "Referenced"
    and "referring" describe which side of the relationship the two
    GenericForeignKeys are on.

        ``referenced_model``  - foreign key to ContentType
        ``referenced_id``     - integer field, to hold the instance ID
        ``referenced_object`` - GenericForeignKey using the above pair

        ``referring_model``   - foreign key to ContentType
        ``referring_id``      - integer field, to hold the instance ID
        ``referring_object``  - GenericForeignKey using the above pair

    The remaining attributes are:

        ``change_time``       - the time at which this relationship starts
        ``notes``             - optional notes about the change

    Transition differs from Slot (below) by not having an end time.
    Consequently there are never gaps in a timeline: an individual
    Transition's end time is implicitly the start time of the next
    (temporally) Transition, if there is one.
    """
    # referenced_model is the content type of the model (class), which you would
    # normally just use in the ForeignKey declaration instead
    referenced_model = models.ForeignKey(ContentType, related_name='r_reference')
    # referenced_id is the actual instance of the referenced_model
    referenced_id = models.PositiveIntegerField()
    referenced_object = generic.GenericForeignKey('referenced_model',
                                                  'referenced_id')
    # the referring_ fields point to the model which would otherwise hold the
    # ForeignKey attribute
    referring_model = models.ForeignKey(ContentType, related_name='r_referring')
    referring_id = models.PositiveIntegerField()
    referring_object = generic.GenericForeignKey('referring_model',
                                                  'referring_id')

    # change_time and notes
    change_time = models.DateTimeField()
    notes = models.TextField(blank = True,
                             null = True, 
                             help_text = "Notes for why this change has been scheduled")
    # we have a custom manager for dataset-level queries
    objects = TransitionManager()
    # and we have methods for jumping off from existing instances
    @property
    def next(self):
        """Returns the next Transition of the same type.

        Called on an instance, this method returns the next Transition
        object against the instance's referenced_object, of the same
        type as the instance's referring_object.
        """
        return self.get_next_by_change_time(
                referenced_model=self.referenced_model,
                referenced_id=self.referenced_id,
                referring_model=self.referring_model)
    @property
    def next_change(self):
        """Returns the time of the next Transition"""
        return self.next.change_time

    @property
    def previous(self):
        """Returns the previous Transition of the same type.

        Called on an instance, this method returns the previous Transition
        object against the instance's referenced_object, of the same
        type as the instance's referring_object.
        """
        return self.get_previous_by_change_time(
                referenced_model=self.referenced_model,
                referenced_id=self.referenced_id,
                referring_model=self.referring_model)
    @property
    def previous_change(self):
        """Returns the time of the previous Transition"""
        return self.previous.change_time

    def schedule_change(self, obj, timestamp, notes=''):
        """Add a new transition to the timeline.

        Creates and returns a new Transition object against the same
        referenced object as self.  The method should be read as the verb
        form of 'schedule': by calling it, you are scheduling a change.
        """
        referring_model = ContentType.objects.get_for_model(obj)
        if referring_model != self.referring_model:
            raise ValueError, "cannot schedule change to object of a different type!"
        referring_id = obj.id
        transition, created = Transition.objects.get_or_create(
                                    referenced_model = self.referenced_model,
                                    referenced_id = self.referenced_id,
                                    referring_model = referring_model,
                                    referring_id = referring_id,
                                    change_time=timestamp,
                                    notes=notes)
        return transition
    class Meta:
        # each referenced model instance may only have a single instance of
        # another given type related to it at a particlar time
        unique_together = (('referenced_model','referenced_id',
                            'referring_model','change_time'))
        # make it naturally order by reverse change_time, which means [0] of
        # querysets will always be the most recent
        ordering = ['-change_time']

class GenericIntermediary(models.Model):
    """Link between two content types.

    This model enables generic foreign keys with arbitrary models on both
    sides of the relationship.  It is enforced using django's content
    types, in essence splitting apart the link between two objects types
    and the specific instances.

    There are just two attributes.

        ``.left`` -- a key to the content type of the model which you
        would normally put in a ``ForeignKey``.

        ``.right`` -- a key to the content type of the model which would
        normally have the ``ForeignKey`` attribute itself.

    Example usage:

        class Foo(models.Model):
            ...
            relationship = models.Foreignkey(GenericIntermediary)
            left_id = models.PositiveIntegerField()
            right_id = models.PositiveIntegerField()
            ...
        
        left_object = relationship.left.get_object_for_this_type(left_id)
        right_object = relationship.right.get_object_for_this_type(right_id)

    If you're doing this a lot, look at ``IntermediaryKey``.
    """
    # this model sets up a relationship between arbitrary types A and B
    # (called left and right to aid visualisation)
    left = models.ForeignKey(ContentType, related_name = "against")
    right = models.ForeignKey(ContentType, related_name = "for")
    
    class Meta:
        unique_together=(('left','right'),)
        
    def __unicode__(self):
        return u'%s against %s' % (self.right, self.left)

class IntermediaryKey(object):
    """Key-like object for accessing objects through GenericIntermediary.

    This class provides a django model attribute for an object that is
    defined by a GenericIntermediary relationship and ID pairing.  It is
    constructed with two positional arguments.

        1. The name and side of the field which is a foreign key on
           GenericIntermediaryKey, joined with double-underscores (ie normal
           django passthrough syntax).  The side is always either ``left`` or
           ``right``.
        2. The name of the field which holds the ID of the instance, for the
           content type referred to in the first argument.

    Example usage:

        class Foo(models.Model):
            ...
            relationship = models.ForeignKey(GenericIntermediary)
            left_id = models.PositiveIntegerField()
            right_id = models.PositiveIntegerField()
            # define
            left_object = IntermediaryKey('relationship__left','left_id')
            right_object = IntermediaryKey('relationship__right','right_id')

        f=Foo.objects.get(pk=1)
        f.left_object  # returns object on left of this relationship
        f.right_object # returns object on right

    This attribute is most useful when implementing a mixin class.
    """
    def __init__(self, gi_key, id_key):
        self.gi_key, self.side = gi_key.split('__')
        self.id_key = id_key
        
    def contribute_to_class(self, cls, name):
        self.name = name
        self.model = cls
        self.cache_attr = "_%s_cache" % name
        # django 1.0.  contenttypes.generic does this (it also doesn't do it in
        # pre-1.0; i'm just being forward compatible...)
        try:
            cls._meta.add_virtual_field(self)
        except AttributeError:
            pass
        setattr(cls, name, self)
        
    def __get__(self, instance, instance_type = None):
        if instance is None:
            raise AttributeError, u'can only call %s via instance' % self.name
        if hasattr(instance, self.cache_attr):
            return getattr(instance, self.cache_attr)
        else:
            rel_obj = None
            gi = getattr(instance, self.gi_key)
            ct = getattr(gi, self.side)
            try:
                rel_obj = ct.get_object_for_this_type(id = getattr(instance,
                                                                   self.id_key))
            except ObjectDoesNotExist:
                pass
            setattr(instance, self.cache_attr, rel_obj)
            return rel_obj


class ScheduleMixin(object):
    """Mixin class which provides scheduling for django models.

    Inheriting from this class and declaring a ``schedules`` attribute
    gives the model time-based foreign keys.

    Usage:

        # models.py
        class Page(ScheduleMixin, models.Model):
            # this is the foreign key which varies according to time.
            schedules = (model,)
            # example
            # schedules = (Style,)

        # views.py
        def detail(request,...):
            page = Page.objects.all()[0]
            # the Slot for Style matching right now, if there is one
            style = page.current_for_model('Style')
            if style is None:
                style = page.default_style
            # do stuff with style
            ...

        # properties
        page.schedule # dictionary of schedules keyed by model,
                      # each entry is an array of slots ordered by time
        page.current  # dictionary of all objects (or None) currently
                      # scheduled, keyed by model name.
        page.next     # returns next scheduled objects (ie, where start time is
                      # later than right now) in same format as current
        page.last     # returns last scheduled objects (ie, where end time is
                      # earlier than right now) in same format as current
                      
        # per-type query methods
        page.schedule_for_model('model') # the array of slots for a model instance
        page.current_for_model('model')  # the model instance currently scheduled,
                                   # or None
        page.next_for_model('model')     # the model instance scheduled next, or
                                   # None
        page.last_for_model('model')     # the model instance which most recently
                                   # finished, or None
                                   
        # scheduling an object
        page.add_to_schedule(model_object, start_datetime, end_datetime,
                             notes)

    """
    def __init__(self, *args, **kwargs):
        """ 
        
        Stores a dictionary of intermediaries, creating them if necessary,
        linking our (parent) class and those declared in self.schedules.
        
        We are on the left of this relationship, things scheduled against
        us are on the right.
        
        """
        super(ScheduleMixin, self).__init__(*args,**kwargs)
        
        left = ContentType.objects.get_for_model(self)
        self._meta.gi = {}
        
        for m in self.schedules:
            right = ContentType.objects.get_for_model(m)
            gi, created = GenericIntermediary.objects.get_or_create(
                left = left,
                right = right)
            self._meta.gi[m.__name__] = gi
            
    @property
    def schedule(self):
        """
        Returns a full schedule of all types against a particular instance.
        """
        d = {}
        for model in self._meta.gi.keys():
            d[model]=self.schedule_for_model(model)
        return d
        
    @property
    def current(self):
        """
        Returns the current instance of each thing scheduled against us.
        """
        d = {}
        for model in self._meta.gi.keys():
            d[model] = self.current_for_model(model)
        return d
        
    def add_to_schedule(self, instance, start_time, end_time, notes=""):
        """Schedules a new model instance against this instance.

        When passed an instance of a schedulable model, with start and end
        times and optional notes, this method adds it to the schedule.
        A SlotError will be raised if there is trouble with the times
        passed.

        Example:
            
            christmas_style = Style.objects.get(...)
            # dec_1st and jan_1st are datetime objects
            page.add_to_schedule(christmas_style, dec_1st, jan_1st,
                                "christmas style for the homepage")
        """

        name = instance.__class__.__name__
        if name in self._meta.gi.keys():
            relationship = self._meta.gi[name]
            # XXX tried to use Slot.objects.create() but there seems to be
            # a bug in it
            new_slot = Slot(relationship = relationship,
                          start_time = start_time, end_time = end_time,
                          notes = notes, slotted_object_id = instance.pk,
                          against_object_id = self.pk)
            new_slot.save()
            return new_slot
        else:
            raise ValueError, \
                    "Cannot schedule %s against %s" % (name,
                                                       self.__class__.__name__)

    def schedule_for_model(self, model):
        if model in self._meta.gi.keys():
            return Slot.objects.filter(relationship = self._meta.gi[model],
                                       against_object_id = self.id)
        else:
            raise ValueError, \
                    "%s is not scheduled against %s" % (model,
                                                        self.__class__.__name__)
    def next(self):
        d = {}
        for model in self._meta.gi.keys():
            d[model]=self.next_for_model(model)
        return d
        
    def next_for_model(self, model):
        """ Returns the next instance slotted against this instance."""
        now = datetime.datetime.now()
        if model in self._meta.gi.keys():
            try:
                slot = Slot.objects.filter(relationship = self._meta.gi[model],
                                           against_object_id = self.id,
                                           start_time__gte = now)[0]
                # we return the actual object, not the slot
                return { 'starts': slot.start_time,
                         'object': slot.slotted }
            except IndexError:
                return None
        else:
            raise ValueError, \
                    "%s is not scheduled against %s" % (model,
                                                        self.__class__.__name__)
    def last(self):
        d = {}
        for model in self._meta.gi.keys():
            d[model]=self.last_for_model(model)
        return d
        
    def last_for_model(self, model):
        now = datetime.datetime.now()
        if model in self._meta.gi.keys():
            try:
                slot = Slot.objects.filter(relationship=self._meta.gi[model],
                                           against_object_id=self.id,
                                           end_time__lte=now).order_by('-end_time')[0]
                # we return the actual object, not the slot
                return { 'ended': slot.end_time,
                         'object': slot.slotted }
            except IndexError:
                return None
        else:
            raise ValueError, \
                    "%s is not scheduled against %s" % (model,
                                                        self.__class__.__name__)

    def current_for_model(self,model,time=None):
        """Return the instance of the given model scheduled for right now.

        This method takes a class name as its argument, plus optional time
        (which defaults to the current time). It returns the scheduled
        object of the given type, or None.
        """
        if time is None:
            time = datetime.datetime.now()
        if model in self._meta.gi.keys():
            try:
                slot = Slot.objects.get(relationship=self._meta.gi[model],
                                        against_object_id=self.id,
                                        start_time__lte=time,
                                        end_time__gte=time)
                # we return the actual object, not the slot
                return slot.slotted
            except Slot.DoesNotExist:
                return None
        else:
            raise ValueError, \
                    "%s is not scheduled against %s" % (model,
                                                        self.__class__.__name__)

class SlotError(Exception):
    
    def __init__(self, slot, type = 'clash', clashes = [], message = ''):
        self.slot = slot
        self.type = type
        self.clashes = clashes
        self.message = message
        
    def __str__(self):
        if self.type == 'clash':
            if len(self.clashes) == 1:
                clash = self.clashes[0]
                return 'error with slot [%s => %s] clashes with [%s => %s]: %s' \
                        % (self.slot.start_time, self.slot.end_time,
                        clash.start_time, clash.end_time,
                        self.message)
            else:
                return 'error with slot [%s => %s] clashes with multiple slots: %s' \
                        % (self.slot.start_time, self.slot.end_time, self.clashes)
        elif self.type == 'timing':
            if self.slot.start_time == self.slot.end_time:
                return 'start and end time are the same: %s == %s' \
                        % (self.slot.start_time, self.slot.end_time)
            elif self.slot.end_time < self.slot.start_time:
                return 'start time %s later than end time %s!' \
                        % (self.slot.start_time, self.slot.end_time)

class Slot(models.Model):
    """Model which ties a B to an A at a certain time.

        start_time - start time
        end_time   - end time
        notes      - optional notes about this entry

    """
    # this is a handle on the model instance linking the two content types,
    # the A and the B
    relationship = models.ForeignKey(GenericIntermediary,editable=False)
    # these are the concrete A and B instances.
    # a schedule is the set of slotted_objects for a given
    # relationship+left_object_id
    against_object_id = models.PositiveIntegerField(editable=False)
    slotted_object_id = models.PositiveIntegerField()
    # The start and end times can't be blank
    start_time = models.DateTimeField()
    end_time = models.DateTimeField()
    notes = models.TextField(blank = True,
                             null = True, 
                             help_text = "Notes for why this has been scheduled ")

    against = IntermediaryKey('relationship__left','against_object_id')
    slotted = IntermediaryKey('relationship__right','slotted_object_id')
    @property
    def slotted_o(self):
        so=self.relationship.right.get_object_for_this_type(
                                                id=self.slotted_object_id)
        return so
    @property
    def against_o(self):
        a=self.relationship.left.get_object_for_this_type(
                                                id=self.against_object_id)
        return a
    class Meta:
        ordering = ['start_time']
    
    def __unicode__(self):
        return u'[%s:%s] against [%s:%s] %s -> %s' % \
                (self.relationship.right, self.slotted.id,
                 self.relationship.left, self.against.id,
                 self.start_time, self.end_time)

    def save(self, force_insert=False, force_update=False):
        """Ensure a schedule has no duplicate entries for a time.
        
        Our start and end time have precedence over everything, and we
        use them to alter the start and end times for the objects
        currently scheduled around them. This happens after we save; and
        we only save our change if it doesn't clash with another slot,
        ie is either entirely within another or entirely surrounding it.
        """
        # The validation in here is record-level, rather than field-level.
        # I'm not sure we can do it with normal validation methods.
        # 
        # First we check for nonsensical data: start == end, or
        # start > end.  This method raises SlotError if required, as
        # does check_for_clash below.  TODO: get a 500 which catches
        # this stuff. TODO #2: work out how to do entire dataset
        # validation. Right now if you change multiple schedules it
        # doesn't work because each item gets .save()d in turn. Lookahead
        # validation anyone? *cough*
        self.check_for_nonsense()
        # Second, get the entire timeline as it stands, excluding ourself
        # if this is not a new slot (determined by if we have .id already)
        timeline = Slot.objects.filter(relationship=self.relationship,
                                       against_object_id=self.against_object_id)
        if self.id:
            timeline = timeline.exclude(id = self.id)
        # Now check our new position against this timeline.
        self.check_for_clashes(timeline)
        # If it doesn't clash, we're going to save ourselves(!) and then
        # alter any overlapping existing slots. 
        #
        # This sets self.id if it wasn't already there.
        super(Slot,self).save()
        # Now we need to get a new copy (cache-bust) of self and the
        # timeline, because we're potentially going to act on them.
        new_self = Slot.objects.get(pk = self.id)
        # TODO: figure out why we seem to need self instead of new_self
        # below
        # This returns a QuerySet ...
        timeline = Slot.objects.filter(relationship=self.relationship,
                                       against_object_id=self.against_object_id)
        # ... which we turn into a ValueQuerySet ...
        values = timeline.values()
        # ... which by casting to a list makes the elements
        # dictionaries; .__dict__ is our own dictionary so we can use .index
        index = list(values).index(new_self.__dict__)
        # 1 second is the delta we use for separating neighbouring slots
        one_second = datetime.timedelta(seconds = 1)
        # We might be (and are likely to be) first or last in the list,
        # so only check previous and next if they exist.
        # we use new_self in our comparisons because it will have rounded off
        # the microseconds
        if index > 0:
            prev = timeline[index - 1]
            if prev.end_time >= new_self.start_time:
                prev.end_time = new_self.start_time - one_second
                prev.save()
        if index < len(timeline) - 1:
            next = timeline[index + 1]
            if next.start_time <= new_self.end_time:
                next.start_time = new_self.end_time + one_second
                next.save()
                
    def check_for_nonsense(self):
        """Check internal integrity of dates"""
        if self.start_time >= self.end_time:
            raise SlotError(self, type='timing', clashes = [])
            
    def check_for_clashes(self, timeline):
        """
        Raise an exception if the slot is wholly within another, or
        wholly surrounds one more more existing slots. 
        """
        try:
            candidate = timeline.get(start_time__lte = self.start_time,
                                   end_time__gte = self.end_time)
        except:
            pass
        else:
            raise SlotError(self, type = 'clash', clashes = [candidate],
                            message = 'slot falls entirely within another')
        candidates = timeline.filter(start_time__gte = self.start_time,
                                       end_time__lte = self.end_time)
        if len(candidates) > 0:
            raise SlotError(self, type = 'clash', clashes = candidates,
                            message = 'slot overwrites one or more existing slots')

